Skip to content

bundler: make --keep-names preserve .name via __name() runtime calls - #35307

Closed
robobun wants to merge 9 commits into
mainfrom
farm/1e9e4bb5/keep-names-emits-name-calls
Closed

robobun wants to merge 9 commits into
mainfrom
farm/1e9e4bb5/keep-names-emits-name-calls

Conversation

@robobun

@robobun robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

bun build --help documents --keep-names as "Preserve original function and class names when minifying", but the flag's only visible effect was to stop the syntax pass from stripping the inline name off an unused function/class expression. Every declared function/class, and every arrow or function expression assigned to a binding, still lost its .name to the identifier renamer. Name-keyed DI registries and serializers break in minified bundles.

// e.js
class UserService {}
function makeThing() { return 1; }
const registry = new Map();
function register(k) { registry.set(k.name, k); }
register(UserService); register(makeThing);
const arrow = () => 1;
console.log([UserService.name, makeThing.name, arrow.name, (function named(){}).name, [...registry.keys()].join("|")].join(","));
bun e.js                                         -> UserService,makeThing,arrow,named,UserService|makeThing
bun build e.js --minify --keep-names | bun -      -> e,o,s,n,e|o   (names NOT kept)
esbuild e.js --bundle --minify --keep-names       -> UserService,makeThing,arrow,named,...

Cause

keep_expr_symbol_name in src/js_parser/p.rs was a no-op stub that returned its input unchanged. All the visitor call sites that feed it (variable declarators, assignments, class field initializers, default parameter values, export default) were already plumbed; the helper just never produced anything. The __name runtime helper in src/runtime.js and the __name entry in ast/runtime.rs existed but had no emitter.

Fix

  • Implement keep_expr_symbol_name to wrap expressions in __name(value, "originalName"), marked can_be_unwrapped_if_unused: IfUnused so tree-shaking still removes unused bindings.
  • Add keep_stmt_symbol_name and emit __name(ref, "originalName") after function/class declarations (including export default function/class).
  • Add named-class-expression handling in e_class (anonymous class expressions were already covered via maybe_keep_expr_symbol_name).
  • Skip any class that defines its own static name member (field/method/getter/setter) so __name() never clobbers a user-supplied value.

All of this is gated on options.features.minify_keep_names && allow_runtime, so output without --keep-names is byte-identical.

After:

bun build e.js --minify --keep-names | bun -  -> UserService,makeThing,arrow,named,UserService|makeThing

The JS API path (Bun.build({ minify: { keepNames: true } })) already read the option; it now has the same effect as the CLI flag.

Tests

  • bundler_minify.test.ts: the two existing keep-names tests are rewritten to run the bundle and assert .name values with minifyIdentifiers: true; added KeepNamesExportDefault, KeepNamesSkipsStaticNameMember, and a negative KeepNamesDisabledByDefault that asserts no __name/name strings are emitted without the flag.
  • esbuild/extra.test.ts: un-todo FunctionHoistingKeepNames1/2 (block-scoped if (1) function f() {} hoisting), which now pass.

Fixes #12304
Fixes #25332
Fixes #15552


[review] gate passed · iteration 1 · 8 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_minify.test.ts test/bundler/esbuild/extra.test.ts
bun test v1.4.0 (017c8d24f)

test/bundler/bundler_minify.test.ts:

# Unhandled error between tests
-------------------------------
1 | })
2 | (function (process, nextTickQueue, drainMicrotasksFn, reportUncaughtExceptionFn) {"use strict";
3 |   var queue, tickInitHooks, process, nextTickQueue = nextTickQueue, drainMicrotasks = drainMicrotasksFn, reportUncaughtException = reportUncaughtExceptionFn;
4 |   var setup = /* @__PURE__ */ __name(() => {
                                        ^
ReferenceError: __name is not defined
      at internal:streams/destroy (internal:streams/destroy:19:30)
      at internal:streams/pipeline (internal:streams/pipeline:5:94)
      at internal:streams/compose (internal:streams/compose:3:95)
      at internal:stream (internal:stream:7:90)
      at node:stream (node:stream:17:90)
      at internal:fs/streams (internal:fs/streams:21:116)
      at ReadStream (node:fs:897:109)
-------------------------------


# Unhandled error between tests
... (truncated)

release without fix: 10 skipped
bun test v1.4.0-canary.1 (963df7387)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [18.82ms]
(pass) bundler > minify/StringAdditionFolding [4.58ms]
(pass) bundler > minify/FunctionExpressionRemoveName [6.09ms]
(pass) bundler > minify/KeepNamesPreservesNames [17.85ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [17.81ms]
(pass) bundler > minify/KeepNamesExportDefault [16.61ms]
(pass) bundler > minify/KeepNamesDecoratorSeesName [17.50ms]
(pass) bundler > minify/KeepNamesTreeShaking [4.82ms]
(pass) bundler > minify/KeepNamesTSNamespace [16.43ms]
(pass) bundler > minify/KeepNamesSkipsStaticNameMember [16.84ms]
(pass) bundler > minify/KeepNamesDisabledByDefault [4.12ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [21.45ms]
(pass) bundler > minify/MergeAdjacentVars [18.29ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [16.16ms]
(pass) bundler > minify/Infinity [3.40ms]
(pass) bundler > minify+whitespace/Infinity [3.78ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [15.61ms]
(pass) bundler > minify/InlineArraySpread [4.01ms]
(pass) bundler > minify/ForAndWhileLoopsWithMissingBlock [16.35
... (truncated)
passes on PR (with fix)
ASAN with fix: 10 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_minify.test.ts test/bundler/esbuild/extra.test.ts
bun test v1.4.0 (017c8d24f)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [724.39ms]
(pass) bundler > minify/StringAdditionFolding [124.33ms]
(pass) bundler > minify/FunctionExpressionRemoveName [134.08ms]
(pass) bundler > minify/KeepNamesPreservesNames [685.33ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [612.04ms]
(pass) bundler > minify/KeepNamesExportDefault [596.32ms]
(pass) bundler > minify/KeepNamesDecoratorSeesName [609.15ms]
(pass) bundler > minify/KeepNamesTreeShaking [112.73ms]
(pass) bundler > minify/KeepNamesTSNamespace [689.58ms]
(pass) bundler > minify/KeepNamesSkipsStaticNameMember [634.72ms]
(pass) bundler > minify/KeepNamesDisabledByDefault [146.64ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [1408.88ms]
(pass) bundler > minify/MergeAdjacentVars [907.69ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [603.85ms]
(pass) bundler > minify/Infinity [179.19ms]
(pass) bundler > minif
... (truncated)

release with fix: 10 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 770ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/38] gen cpp.rs (cppbind)
[2/38] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 237 extern-C blocks audited
[3/38] gen JS modules (bundle-modules)
Preprocess modules (7767ms)
Bundle modules (40ms)
Postprocesss modules (205ms)
Bundle Functions (949ms)
Generate Code (27ms)

[9.01s] Bundled "src/js" for production
  2200 kb
  167 internal modules
  13 native modules
  90 internal functions across 19 files
[3/31] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

^[[1m^[[92m   Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_windows_sys v0.0.0 (/workspace/bun/src/windows_sys)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m   Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m   Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys
... (truncated)
diff hotspot
src/codegen/bundle-functions.ts     |   6 +-
 src/js_parser/p.rs                  |  74 +++++++++++-
 src/js_parser/visit/mod.rs          |  12 ++
 src/js_parser/visit/visit_binary.rs |  27 ++++-
 src/js_parser/visit/visit_expr.rs   |  42 ++++---
 src/js_parser/visit/visit_stmt.rs   |  60 ++++++++-
 test/bundler/bundler_minify.test.ts | 234 +++++++++++++++++++++++++++++++-----
 test/bundler/esbuild/extra.test.ts  |   3 +-
 8 files changed, 401 insertions(+), 57 deletions(-)

gate history · 5 passed · 0 rejected · iteration 1

evidence per changed file
file                                 reads  edits  tests
src/codegen/bundle-functions.ts          4      5      0
src/js_parser/p.rs                       6      7      0
src/js_parser/visit/mod.rs               4      1      0
src/js_parser/visit/visit_binary.rs      1      1      0
src/js_parser/visit/visit_expr.rs        2      4      0
src/js_parser/visit/visit_stmt.rs       13     14      0
test/bundler/bundler_minify.test.ts      1     12      0
test/bundler/esbuild/extra.test.ts       1      1      0

@robobun

robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green on all lanes that touch the bundler/parser/codegen; the remaining CI reds on build #87081 are unrelated to this change. Ready for review.

CI on #87081:

  • test/cli/install/bun-upgrade.test.ts (windows-aarch64): error: Canary builds are not available for this platform yet (release infrastructure; reported to main-break triage)
  • test/regression/issue/36577.test.ts (windows-x64): marked [pre-existing], also failing on main
  • test/cli/test/parallel.test.ts, test/js/bun/terminal/terminal-platform-gaps.test.ts: known [flaky]

All eight minify/KeepNames* bundler tests and the un-todo'd FunctionHoistingKeepNames tests pass on every lane.

Reproduced with:

printf 'class UserService {}\nfunction makeThing(){return 1}\nconst arrow=()=>1;\nconsole.log(UserService.name, makeThing.name, arrow.name, (function named(){}).name);\n' > /tmp/e.js
bun build /tmp/e.js --minify --keep-names | bun -
# before: e o s n
# after:  UserService makeThing arrow named

Builtin codegen: bundle-functions.ts drops keepNames (identifiers are not minified there, and the only named function expressions in src/js/builtins bind to a matching identifier). bundle-modules.ts keeps --keep-names because crypto.createHash.name, EventEmitter.prototype.emit.name, and the _write stack-trace test depend on unused function-expression names not being stripped; its (function(){...}) wrapper already contains the bundler-emitted __name helper.

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Implement --keep-names to prevent mangling function .name accesses #12304 - Requests implementation of --keep-names to preserve function .name during minification; this PR implements exactly that via __name() runtime calls
  2. --keep-names flag for bun build does not have the intended effect #25332 - Reports --keep-names flag is a no-op with no effect on output; this PR fixes the flag to actually emit name-preserving code
  3. Bun elides function names expected by Lighthouse #4958 - Reports Bun elides function names expected by Lighthouse; this PR preserves .name when --keep-names is used

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #12304
Fixes #25332
Fixes #4958

🤖 Generated with Claude Code

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Linked #12304 and #25332 (both are the bundler --keep-names flag).

#4958 is a different path: it's the runtime transpiler (bun run) stripping an unused function-expression name before .toString(), with no --keep-names involved. This PR does not change the runtime transpiler, so I've left that one unlinked.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Name preservation

Layer / File(s) Summary
Name-preservation helpers
src/js_parser/p.rs
Adds feature gating, custom static name detection, and conditional runtime __name wrapping for expressions and statements.
Expression and statement integration
src/js_parser/visit/...
Preserves eligible names across class transformations, defaults, assignments, exports, and lowered classes.
Bundler runtime support and validation
src/codegen/bundle-functions.ts, test/bundler/...
Reinjects __name when captured code requires it and validates runtime naming, tree shaking, static-name behavior, disabled behavior, and minified hoisting cases.

Possibly related PRs

  • oven-sh/bun#34932: Changes related default-export function naming and runtime .name behavior.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement keep-names preservation for function/class .name access and related export/binding forms, matching both linked issues.
Out of Scope Changes check ✅ Passed No clearly unrelated changes are present; the extra edge-case updates support the keep-names fix.
Title check ✅ Passed The title clearly summarizes the main change: preserving minified function and class names through runtime __name() calls.
Description check ✅ Passed The description explains the problem, implementation, scope, edge cases, linked issues, and verification results under the required sections.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/js_parser/visit/visit_stmt.rs (2)

935-958: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

--keep-names doesn't preserve function names for TS namespace-exported functions.

The namespace-export branch here is a separate if from the else if !mark_as_dead { ... } branch that calls p.keep_stmt_symbol_name(...) (see Line 998). So namespace Foo { export function bar() {} } never gets its name preserved when minifying with --keep-names, unlike the equivalent class case in s_class (Line 1092-1102), where the namespace-export path is folded into the same block that includes the keep_stmt_symbol_name call.

🩹 Proposed fix
         stmts.push(Stmt::assign(
             p.new_expr(
                 E::Dot {
                     target: Expr::init_identifier(enclosing_namespace_arg_ref, stmt.loc),
                     name: original_name.into(),
                     name_loc: func_name.loc,
                     ..Default::default()
                 },
                 stmt.loc,
             ),
             Expr::init_identifier(func_name.ref_, func_name.loc),
         ));
+        if let Some(keep) = p.keep_stmt_symbol_name(stmt.loc, name_ref, original_name) {
+            stmts.push(keep);
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_parser/visit/visit_stmt.rs` around lines 935 - 958, Update the
namespace-export branch in the statement visitor to invoke
p.keep_stmt_symbol_name(...) for exported functions when --keep-names is
enabled, matching the existing behavior in the subsequent !mark_as_dead branch
and the analogous namespace-export handling in s_class. Preserve the current
namespace assignment generation while ensuring the function’s original name is
retained.

1078-1102: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Hoist the custom static-name check before lower_class. lower_class mutates data.class.properties and can move decorated fields out of the class body, so checking class_has_custom_static_name(&data.class) afterward can miss a user-defined static name and append a keep_stmt_symbol_name(...) that clobbers it. src/js_parser/visit/visit_stmt.rs:1087-1099

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_parser/visit/visit_stmt.rs` around lines 1078 - 1102, Compute and
store the result of class_has_custom_static_name(&data.class) before calling
lower_class in the class statement handling flow. Use this stored result after
lowering to decide whether keep_stmt_symbol_name should be appended, preserving
the existing class-name and export conditions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/js_parser/visit/visit_stmt.rs`:
- Around line 935-958: Update the namespace-export branch in the statement
visitor to invoke p.keep_stmt_symbol_name(...) for exported functions when
--keep-names is enabled, matching the existing behavior in the subsequent
!mark_as_dead branch and the analogous namespace-export handling in s_class.
Preserve the current namespace assignment generation while ensuring the
function’s original name is retained.
- Around line 1078-1102: Compute and store the result of
class_has_custom_static_name(&data.class) before calling lower_class in the
class statement handling flow. Use this stored result after lowering to decide
whether keep_stmt_symbol_name should be appended, preserving the existing
class-name and export conditions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6a017367-2fa3-4470-8cd7-823f51a08a11

📥 Commits

Reviewing files that changed from the base of the PR and between 892b1da and 5bbe51c.

📒 Files selected for processing (5)
  • src/js_parser/p.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/bundler_minify.test.ts
  • test/bundler/esbuild/extra.test.ts

Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/p.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_parser/visit/visit_stmt.rs:874-884 — For an anonymous export default class {} with decorators (or a bare accessor field), the decorator-name-injection at line 830 has already set class.class.class_name = Some(data.default_name) by the time keep_name is computed here, so load_name_from_ref returns the bundler-internal {source}_default symbol name (e.g. "d_default") instead of hitting the DEFAULT_ALIAS fallback. The sibling SFunction arm at lines 584-589 avoids this by computing name before injecting data.default_name; capturing class_name before line 824 (or falling through to DEFAULT_ALIAS when n.ref_ == data.default_name.ref_) would fix it.

    Extended reasoning...

    What the bug is

    The export default class keep-names block computes keep_name from class.class.class_name at line 875, but by that point the pre-existing decorator-name-injection at lines 824-832 may have already overwritten it with data.default_name:

    // lines 824-832 (pre-existing)
    if class.class.has_decorators || class.class.should_lower_standard_decorators {
        if class.class.class_name.is_none() || ...ref_.is_empty() {
            class.class.class_name = Some(data.default_name);
        }
    }
    // ...
    // line 835 — follow-up commit 72e69dfc captures has_custom_static_name here,
    //            but class_name has ALREADY been injected above
    // ...
    // lines 875-877 (new in this PR)
    let keep_name: &[u8] = match class.class.class_name {
        Some(n) if !n.ref_.is_empty() => p.load_name_from_ref(n.ref_),
        _ => js_ast::ClauseItem::DEFAULT_ALIAS,
    };

    data.default_name.ref_ is a real symbol created by create_default_name() (p.rs:3995) whose original_name is the bundler-internal identifier "{source_path_ident}_default" — not the string "default". So the match takes the Some(n) if !n.ref_.is_empty() arm, load_name_from_ref (p.rs:756-763) returns symbols[...].original_name.slice(), and the DEFAULT_ALIAS fallback is never reached.

    Code path that triggers it

    Any anonymous export default class {} whose has_decorators or should_lower_standard_decorators is set. Per the comment at lines 820-823, that includes classes with only an auto-accessor field and no decorators at all:

    // d.ts, --keep-names --minify-identifiers
    export default class { accessor x = 1 }
    // or with experimentalDecorators:
    // @dec export default class {}

    Why existing code doesn't prevent it

    The follow-up commit 72e69df moved the has_custom_static_name capture to line 835 (before lower_class), but that is still after the injection at line 830 and does not capture the pre-injection class_name. The earlier inline comment on this PR about lower_class()/mem::take() mutations covers different mutation sites (lines 841 and 868); the injection at 830 is a third, earlier one it does not address.

    Contrast with the sibling SFunction arm at lines 584-589, which computes name from func.func.name in the same expression that injects data.default_name, so an anonymous export default function() {} correctly gets DEFAULT_ALIAS. The SClass arm is asymmetric.

    Step-by-step proof

    1. Input: /d.ts = export default class { accessor x = 1 } with --keep-names --minify-identifiers.
    2. parse_stmt.rs creates data.default_name via create_default_name(), whose symbol original_name is "d_default" (from the source path).
    3. visit_stmt.rs:824: should_lower_standard_decorators is true (auto-accessor lowering), class_name is None.
    4. Line 830: class.class.class_name = Some(data.default_name) — now class_name points at the d_default symbol.
    5. Line 875: match class.class.class_name sees Some(n); n.ref_ is a real symbol so !n.ref_.is_empty() is true.
    6. p.load_name_from_ref(n.ref_) returns symbols[inner_index].original_name.slice() = b"d_default".
    7. Line 880 emits __name(default_ref, "d_default").
    8. At runtime, the imported class's .name === "d_default" — a leaked internal bundler identifier — instead of the spec value "default" (which the KeepNamesExportDefault test asserts for the non-decorated /d.js case).

    Impact

    Narrow: opt-in flag × anonymous export default class × decorators/accessor. Not a regression relative to before this PR (where .name was already wrong under --minify-identifiers), but it's newly-added logic emitting the wrong string for a case whose sibling arm handles it correctly — REVIEW.md's "Fix the whole class — parallel switch arms".

    Fix

    Mirror the SFunction arm: capture keep_name from class.class.class_name before line 824. Alternatively, in the match at 875, fall through to DEFAULT_ALIAS when n.ref_ == data.default_name.ref_. Optionally add a decorated/accessor variant to KeepNamesExportDefault.

Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/visit/visit_expr.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_parser/visit/visit_stmt.rs:838-841 — The keep_name capture runs after the decorator-name-injection block at lines 824-832, so for an anonymous export default @dec class {} (or export default class { accessor x }) it reads the synthesized data.default_name ref and emits __name(ref, "<src>_default") instead of __name(ref, "default") — leaking an internal identifier as the runtime .name where the spec (§16.2.3.7) mandates "default". Move this capture above line 824 (or add if n.ref_ != data.default_name.ref_ to the Some(n) guard) so the anonymous case falls through to DEFAULT_ALIAS.

    Extended reasoning...

    What the bug is

    Commit 05f92bc fixed the earlier lower_class/mem::take ordering issue by capturing keep_name and has_custom_static_name before lower_class() runs. But it placed that capture at lines 838-841, which is still after the pre-existing decorator-name-injection block at lines 824-832. That block gives an anonymous export default class a synthetic name whenever decorator lowering needs one:

    // visit_stmt.rs:824-832
    if class.class.has_decorators || class.class.should_lower_standard_decorators {
        if class.class.class_name.is_none() || class.class.class_name.unwrap().ref_.is_empty() {
            class.class.class_name = Some(data.default_name);
        }
    }

    So by the time the keep_name match runs, class.class.class_name is Some(data.default_name) for an anonymous decorated class, and the Some(n) if !n.ref_.is_empty() arm is taken instead of the intended _ => DEFAULT_ALIAS fallback.

    Code path

    data.default_name for an anonymous export default class is created via create_default_name (p.rs:3995-4009), whose symbol original_name is format!("{}_default", source.path.name().fmt_identifier()). load_name_from_ref on a RefTag::Symbol ref (p.rs:761-763) returns that original_name slice. So for a source file d.js, keep_name becomes b"d_default", and line 881 emits __name(default_ref, "d_default").

    Per ES §16.2.3.7 (NamedEvaluation of ClassDeclaration : class ClassTail), an anonymous export default class {} gets .name === "default". The _ => js_ast::ClauseItem::DEFAULT_ALIAS arm (b"default") is clearly what was intended for this case — it just never fires when decorator lowering has already injected a name.

    Why existing tests don't catch it

    KeepNamesExportDefault's /d.js is export default class {} with no decorators or auto-accessors, so has_decorators and should_lower_standard_decorators are both false, line 830 doesn't fire, class_name stays None, and the DEFAULT_ALIAS arm is correctly taken. Adding a decorator or an accessor field to /d.js would make the test assert "default" and observe "d_default".

    Step-by-step proof

    1. Input: export default @dec class {} (or export default class { accessor x = 1 }) with --keep-names.
    2. Parse: class_name = None; has_decorators = true (or should_lower_standard_decorators = true); data.default_name = create_default_name(...) with original_name = "<src>_default". Line 816-818 ensures data.default_name.ref_ is a real symbol.
    3. visit_stmt.rs:824-832: condition true, class_name.is_none() → class.class.class_name = Some(data.default_name).
    4. visit_stmt.rs:838-841: match class.class.class_name { Some(n) if !n.ref_.is_empty() => p.load_name_from_ref(n.ref_) } → "<src>_default".
    5. visit_stmt.rs:881: keep_stmt_symbol_name(stmt.loc, data.default_name.ref_, "<src>_default") → emits __name(ref, "<src>_default").
    6. Runtime: .name === "d_default" (or "entry_default", etc.); expected "default".

    Not a duplicate

    • The resolved lower_class/mem::take thread is what this capture was added to fix; this bug was introduced by that fix's placement.
    • The open comment at line 1112 (decorator-suffix ordering) is about when __name runs relative to decorator callbacks, not what string it carries.
    • The open e_class/should_lower_standard_decorators comment is about the class-expression path in visit_expr.rs.

    Impact & fix

    Narrow — requires anonymous export default class + decorators/auto-accessors + opt-in --keep-names — and not a regression (the flag was a no-op before this PR). But it leaks an internal synthesized identifier into user-visible .name from newly-added logic whose intent (the DEFAULT_ALIAS arm) is defeated. One-line fix: hoist the keep_name/has_custom_static_name capture above line 824, or add if n.ref_ != data.default_name.ref_ to the Some(n) guard so the injected default-name ref falls through to DEFAULT_ALIAS.

Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
@robobun

robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:47 AM PT - Aug 1st, 2026

⏳ @robobun, your commit 4a2aa48 is still building in Build #87081, but has 2 failures so far (All Failures):

Comment thread src/codegen/bundle-functions.ts Resolved
Comment thread src/codegen/bundle-functions.ts Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/codegen/bundle-functions.ts (1)

329-349: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

__name helper reinjection is skipped entirely for sloppy-mode captures, even when needed.

usesKeepNames is computed unconditionally on captured, but the tamper-safe var __name=... reinjection only happens inside captured.replace(...), which is skipped whenever fn.directives.sloppy is true (finalReplacement = captured unchanged). If a sloppy builtin's captured body contains a keep-names-generated __name(...) call (plausible since keepNames: true is passed unconditionally to every builtin compile at Line 317), the emitted code references an undefined __name and will throw a ReferenceError at runtime — this would be a boot-time crash for that builtin, not just a lint nit.

🐛 Proposed fix: inject the helper regardless of sloppy mode
     const finalReplacement =
-      (fn.directives.sloppy
-        ? captured
+      (fn.directives.sloppy && !usesKeepNames
+        ? captured
         : captured.replace(
             /function\s*\(.*?\)\s*{/,
-            '$&"use strict";' +
+            "$&" +
+              (fn.directives.sloppy ? "" : '"use strict";') +
               (usesKeepNames
                 ? 'var __name=(t,n)=>(__intrinsic__Object.__intrinsic__defineProperty(t,"name",{value:n,configurable:!0}),t);'
                 : "") +
               (usesDebug ? createLogClientJS("BUILTINS", fn.name) : "") +
               (usesAssert ? createAssertClientJS(fn.name) : ""),
           )
       )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/codegen/bundle-functions.ts` around lines 329 - 349, Update the
finalReplacement construction so keep-names helper reinjection occurs for sloppy
captures as well as strict captures. When usesKeepNames detects __name( in
captured, prepend the existing tamper-safe __name helper to the emitted body
regardless of fn.directives.sloppy, while preserving the current strict-mode
directive and debug/assert injections.
src/js_parser/visit/visit_stmt.rs (1)

905-1066: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Server-components wrapped named function exports don't get --keep-names applied.

When p.options.features.server_components.wraps_exports() is true and the function is exported (lines 980-1006), the export is rewritten into export var <name> = registerClientReference(<func>, ...) and pushed directly via p.s(S::Local {...}), bypassing both keep_stmt_symbol_name (called in the sibling else branch at Line 1009) and the generic visit_decl/maybe_keep_expr_symbol_name path. The function's original name is not preserved for this specific combination of --keep-names + server components client-reference wrapping.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_parser/visit/visit_stmt.rs` around lines 905 - 1066, Apply keep-names
handling in the server-components wrapping branch of s_function before or
alongside pushing the generated Local declaration. Ensure the rewritten
client-reference export preserves the original function name using the existing
keep_stmt_symbol_name path, without changing behavior when keep-names is
disabled.
src/js_parser/p.rs (1)

5412-5435: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Anonymous decorated class expressions can clobber a custom static name under --keep-names.
keep_expr_symbol_name() re-checks ExprData::EClass after decorator lowering has already wrapped the class, so the class_has_custom_static_name() guard no longer applies and __name() can overwrite the user-defined name. Snapshot that flag before lowering and thread it through here instead of re-deriving it from the lowered expression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js_parser/p.rs` around lines 5412 - 5435, Preserve whether the original
class has a custom static name before decorator lowering, then pass that flag
into keep_expr_symbol_name instead of re-checking ExprData::EClass on the
lowered expression. Use the captured flag to bypass __name() for anonymous
decorated classes with custom static name, while preserving existing behavior
for other expressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/codegen/bundle-functions.ts`:
- Around line 329-349: Update the finalReplacement construction so keep-names
helper reinjection occurs for sloppy captures as well as strict captures. When
usesKeepNames detects __name( in captured, prepend the existing tamper-safe
__name helper to the emitted body regardless of fn.directives.sloppy, while
preserving the current strict-mode directive and debug/assert injections.

In `@src/js_parser/p.rs`:
- Around line 5412-5435: Preserve whether the original class has a custom static
name before decorator lowering, then pass that flag into keep_expr_symbol_name
instead of re-checking ExprData::EClass on the lowered expression. Use the
captured flag to bypass __name() for anonymous decorated classes with custom
static name, while preserving existing behavior for other expressions.

In `@src/js_parser/visit/visit_stmt.rs`:
- Around line 905-1066: Apply keep-names handling in the server-components
wrapping branch of s_function before or alongside pushing the generated Local
declaration. Ensure the rewritten client-reference export preserves the original
function name using the existing keep_stmt_symbol_name path, without changing
behavior when keep-names is disabled.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b4d660f5-a4c8-43ed-bc2f-059e9a74f5f1

📥 Commits

Reviewing files that changed from the base of the PR and between 5bbe51c and 963df73.

📒 Files selected for processing (7)
  • src/codegen/bundle-functions.ts
  • src/js_parser/p.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_binary.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/bundler_minify.test.ts

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

On the three outside-diff findings (reviewed against 963df73, before 017c8d2):

  • bundle-functions.ts sloppy-mode injection skip: no longer applies; 017c8d2 drops keepNames from bundle-functions.ts entirely, so there is no __name() emission and no helper injection in that path.
  • server-components wraps_exports() branch in s_function: left as-is intentionally. registerClientReference returns a reference object, not the original function, so preserving .name on the synthetic binding is not meaningful, and server-components bundling does not combine with --minify-identifiers in practice.
  • anonymous decorated class expression with static name: verified against released bun 1.3.14; const C = @dec class { static name = "customC" } already evaluates to C.name === "C" there (the standard-decorator lowering routes through __decorateElement/its internal __name with the context name). This PR matches the pre-existing lowering behaviour rather than regressing it, so I have left it for a follow-up on decorator lowering rather than widening this change.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found in this revision — the earlier rounds' feedback (tree-shaking flag, decorator-lowering ordering, simple parameter defaults, bundle-functions keepNames) is all addressed. I'm deferring rather than approving because this is a ~400-line feature implementation across six parser/visitor files with restructured control flow in e_class, and it activates the previously-inert --keep-names in bundle-modules.ts (release-build internal modules now get __name() calls) — worth a human sanity check on the size/perf side.

What was reviewed:

  • visit_binary.rs: &&= added to the logical-assign arm; verified the {}.field ??= v HMR fold stays gated off for &&= (correct — {}.x &&= v is undefined, not v).
  • e_class restructure: early-return removed so the __name() wrap runs on the lowered-decorator result too; has_custom_static_name and original_class_name are captured pre-lowering.
  • s_class/s_function/export-default: __name insertion order relative to lowered decorator suffix and namespace assignments.
  • bundle-functions.ts now drops keepNames entirely (no injected helper, no sloppy-branch hole).
Extended reasoning...

Overview

This PR turns the --keep-names bundler flag from a near-no-op into a real feature by implementing keep_expr_symbol_name / keep_stmt_symbol_name to emit __name(value, "originalName") calls, mirroring esbuild. It touches p.rs (helpers + class_has_custom_static_name), four visitor files (mod.rs for parameter defaults, visit_binary.rs for ??=/||=/&&=, visit_expr.rs for class expressions with restructured decorator-lowering flow, visit_stmt.rs for function/class declarations and export-default), drops keepNames from bundle-functions.ts, and adds/rewrites ~230 lines of bundler tests that run the output and assert .name values.

Security risks

None identified. The feature is opt-in (--keep-names) and only affects bundler output. The one indirect effect — release-build internal modules now emit __name() calls via the pre-existing --keep-names in bundle-modules.ts — routes through the same userland Object.defineProperty that __export/__toESM/etc. already use, so it is not a new tamper surface (as the author noted in-thread). Builtin functions (bundle-functions.ts) now drop keepNames entirely, so the tamper concern I raised there is resolved.

Level of scrutiny

Medium-high. This is a real feature implementation, not a mechanical fix: ~400 lines across the parser/visitor, restructured control flow in e_class (the standard-decorator early-return was removed and the unused-name-stripping block moved into an else), and insertion-order logic in s_class that splices __name() between the class stmt and its lowered decorator suffix. Everything is gated on minify_keep_names && allow_runtime, so the without-flag output is unchanged (covered by KeepNamesDisabledByDefault). The change also affects release-build internal-module codegen size/startup (every nested function/class/arrow gets a defineProperty call at load), which the author acknowledged as a possible follow-up.

Other factors

The PR went through several review rounds; all nine inline findings from prior runs are resolved (fixes in 05f92bc, 746e810, d3cfb29, 017c8d2) or explicitly deferred with empirical justification (the anonymous-decorated-class + static name case matches released-Bun behavior; the server-components wraps_exports branch is intentionally skipped). Test coverage is solid — the new tests execute bundles and assert runtime .name, cover tree-shaking, TS namespaces, decorators, static-name skipping, and the negative case. Given the scope and the codegen side-effect on internal modules, this is past the auto-approval bar; a human should take a look.

robobun added 9 commits August 1, 2026 09:41
--keep-names was documented as preserving function/class names under
minification but the core helper keep_expr_symbol_name was a no-op stub
that returned its input unchanged. Every declared function/class and
every arrow/function expression bound to an identifier still lost its
.name to the identifier renamer, breaking name-keyed DI registries and
serializers in minified bundles.

This implements keep_expr_symbol_name to wrap expressions in
__name(value, "originalName") (the runtime helper already existed in
runtime.js), adds keep_stmt_symbol_name for function/class declarations
and export default, and plumbs both through the statement/expression
visitors. Classes that define their own static name member are skipped
so the user's value is not overwritten.

The existing keep-names bundler tests only asserted output shape with
minifyIdentifiers disabled; they now run the bundled output and assert
the actual .name values with identifiers minified. Two previously
todo'd esbuild FunctionHoistingKeepNames tests are also enabled.
…before lower_class

Emit __name for functions exported from a TypeScript namespace (the
namespace-export branch in s_function was a separate if arm that bypassed
the keep_stmt_symbol_name call).

Capture class_has_custom_static_name before lower_class runs in both
s_class and the export-default class arm, since lowering can hoist static
members out of the property list.
…_name before mutation

class_has_custom_static_name skipped IsComputed properties, missing
static ["name"] = ... whose key is an EString with IsComputed set. The
inner EString match already filters non-constant computed keys, so drop
the flag guard.

In the export-default class arm, compute keep_name from class.class
before lower_class / the server-components mem::take so a named default
class still sees its real name.
…ssign, fix ordering/tree-shaking edges

- Mark the trailing __name() S::SExpr with does_not_affect_tree_shaking so
  an unused top-level declaration is not pinned by its own keep-names call.
- s_class: insert __name() immediately after the class inside lower_class
  output (before decorator suffix statements) so decorator callbacks observe
  the preserved name; same in the export-default class arm.
- s_export_default(class): capture keep_name/has_custom_static_name before
  decorator-name injection so an anonymous default class with
  accessors/decorators still gets "default", not the internal
  {source}_default symbol.
- visit_binary: extend the BinAssign keep-names handling to ??=, ||=, &&=
  (all perform NamedEvaluation with the LHS name per ES 13.15.2).
- e_class: hoist the static-name check before decorator lowering and wrap
  the lowered result for named class expressions that take the
  should_lower_standard_decorators early return.
…s from builtin codegen

visit_args wraps a simple BIdentifier parameter default the same way the
destructuring-binding default paths already do, so
function f(cb = () => {}) {} keeps cb.name under --minify-identifiers.

bundle-functions.ts / bundle-modules.ts passed keepNames for its old
"don't strip unused expression names" side effect. Now that the flag
emits __name() calls, the helper lands outside the captured function
body / builtin wrapper and is undefined at runtime, so drop it. Those
scripts do not minify identifiers, so .name inference from bindings is
unaffected.
bundle-functions.ts captures only the source between $$capture_start$$
and $$capture_end$$, so the bundler's __name runtime helper (emitted
at the top of the output) is discarded and the captured body references
an undefined __name. Detect __name( in the captured body and inject a
local definition after "use strict";.

bundle-modules.ts wraps the full per-file bundler output in
(function(){...}), which already includes the __name helper, so restore
--keep-names there.
…ed __name helper

Route the keep-names helper through the JSC intrinsic so builtin function
bodies stay tamper-safe, matching the existing $Object.$defineProperty
usage elsewhere in src/js/builtins.
bundle-functions.ts does not minify identifiers, and the only named
function expressions in src/js/builtins are bound to a matching
identifier (var getStringWidth = function getStringWidth(...)), so
binding-inferred .name is already correct. Dropping keepNames avoids
the __name() wrapper calls entirely, which removes the need for the
capture-window helper injection and its tamper-safety / sloppy-branch
edge cases.

bundle-modules.ts keeps --keep-names: internal modules have named
function expressions whose .name existing tests depend on
(crypto.createHash, EventEmitter.prototype.emit, streams _write), and
the (function(){<full output>}) wrapper already includes the bundler's
__name helper at module scope.
@robobun
robobun force-pushed the farm/1e9e4bb5/keep-names-emits-name-calls branch from 1328250 to 4a2aa48 Compare August 1, 2026 10:27
Comment thread src/codegen/bundle-functions.ts Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/visit/visit_binary.rs Resolved
Comment thread src/js_parser/visit/visit_expr.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved
Comment thread src/js_parser/visit/visit_stmt.rs Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found in this pass, but this is a ~400-line parser/visitor change implementing a user-facing bundler feature with knock-on effects on internal-module codegen, so it warrants a human sign-off.

What was reviewed:

  • keep_expr_symbol_name / keep_stmt_symbol_name emitters and all call sites (declarators, assignments incl. &&=/||=/??=, param defaults, export default, TS namespaces, s_class decorator ordering).
  • e_class control-flow restructure (early return → else) and the class_has_custom_static_name skip.
  • bundle-functions.ts dropping keepNames — the sibling bundle-modules.ts still passes --keep-names, so release-build internal modules now run __name() (userland Object.defineProperty) at load; the author flagged this as a possible follow-up.
  • comment-cop has 7 open inline notes on the new code comments.
Extended reasoning...

Overview

Implements --keep-names for bun build by making keep_expr_symbol_name / keep_stmt_symbol_name emit __name(value, "originalName") calls, and wiring them into every relevant visitor site: variable declarators, plain and logical assignment (= ??= ||= &&=), simple and destructuring parameter defaults, class field initializers, export default function/class, TS namespace export function/class, function/class declarations, and named class expressions (including the standard-decorator early-return path in e_class). Adds a class_has_custom_static_name guard so a user-written static name is not clobbered. Drops keepNames from bundle-functions.ts codegen. Rewrites the two existing keep-names tests to run the bundle and adds seven new itBundled cases plus un-todos two esbuild extra tests.

Security risks

None direct. The one tamper-safety concern raised earlier — __name reading userland Object.defineProperty inside JSC builtin functions — was resolved by dropping keepNames from bundle-functions.ts. bundle-modules.ts (release-build internal modules) still passes --keep-names, so those modules now execute the runtime.js __name helper at load; the author's position is that this is the same surface as the existing __export/__toESM helpers. That is a reasonable argument but is a design/perf tradeoff a maintainer should confirm.

Level of scrutiny

High. This touches the core JS parser visit pass across five files, restructures control flow in e_class (removes an early return), inserts synthesized statements into the middle of lower_class output, and changes what every release-build internal module executes at load time. It went through several review iterations with non-trivial fixes (decorator ordering, mem::take capture, simple parameter defaults, sloppy-mode injection). This is a feature implementation, not a mechanical fix.

Other factors

  • Test coverage is good: the new tests run the minified bundle and assert exact .name values across the variant matrix, plus a negative test that no __name/name strings appear without the flag, a tree-shaking test, and a CJS-shape regression test for #15552.
  • All prior inline findings from the bug-hunting system are marked resolved.
  • github-actions comment-cop has 7 unresolved inline comments (dated today) flagging the new multi-line code comments under the repo's "paragraph-long comment justifying a workaround" rule; the author has not responded to those yet.
  • The author explicitly left two edges as follow-ups (anonymous decorated class expr with static name; server-components wraps_exports branch) — both were verified to match pre-existing behaviour rather than regress it, but a maintainer should agree those are acceptable to defer.

@robobun

robobun commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #40503, which implements the same feature with the same esbuild-style __name helper. That PR also covers object property values and bun build --no-bundle, and it un-todos the ported esbuild tests.

I left a comment on #40503 with the cases from this branch that it does not cover yet: the static name member guard, the ??= / ||= / &&= case, and the bundle-functions.ts codegen change. They can be lifted from here.

#12304 and #15552 should close when #40503 lands. #25332 is already closed as a duplicate of #12304.

@robobun robobun closed this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants